feat(objectql): ValidationError publishes its error code as a constant — the last row of #16159 - #16443
Conversation
The last row of #16159's eleven-row census. `readonly code = 'VALIDATION_FAILED'` becomes `readonly code = VALIDATION_FAILED_CODE`, with the exported constant holding text byte-identical to the literal it replaces and re-exported from the `index.ts` barrel beside the class. The docs already teach `catch it by code, not instanceof` — following that for record validation meant re-spelling the wire string in the consumer's own package, which acquires a `check:error-code-provenance` stamp site there and can drift from what the engine throws with no compile error. ⛔ Does NOT converge `VALIDATION_FAILED` with the sibling `EMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'`; #16159 leaves that question unruled and a pin test asserts the two are still two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 1 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cd705bf829b205195c71d53855edc14175527706 && git checkout cd705bf829b205195c71d53855edc14175527706
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c14ebfeb29d8d3bc0ae9b148132a78b86dec94bc fc1e765838dfcac13b81db736b514f31646f656b && git checkout -B drift-repro c14ebfeb29d8d3bc0ae9b148132a78b86dec94bc && git merge --no-ff fc1e765838dfcac13b81db736b514f31646f656b
node scripts/docs-audit/affected-docs.mjs --json c14ebfeb29d8d3bc0ae9b148132a78b86dec94bc
|
Release authorised — all three pre-landing conditions met at
|
| # | condition | reading |
|---|---|---|
| ① | contract-review PASS bound to the head that will land | PASS at fc1e765838dfcac13b81db736b514f31646f656b; head re-read after the verdict and unmoved |
| ② | check-clause2-carriers --pair 16443 |
REAL_EXIT=0 (redirect-then-read, ⛔ never through a pipe) — "the clause-② declaration is readable in the fixed spelling and both carriers agree". Checker blob 751b4a6e4 == origin/main's, so ⛔ not a stale checker |
| ③ | completed == total AND failing == 0 |
37/37, 0 failing, mergeable_state=clean — ⛔ not a required-checks subset |
needs:contract-review cleared from both carriers with a comparative read-back proving before − label exactly, nothing stripped: PR #16443 [documentation, size/m, tests, tooling], card #16159 [enhancement, pm:dispatched, finding, domain:engine, priority:p3].
⭐ What the review actually established, since a PASS is only worth its measurements
The dispatching seat expected Clause-②: no ("nothing changes except by ADDITION"). That expectation was wrong, the round falsified it, and the review confirmed the round:
- Limb 1 fires. An added export moves the exported name set —
dist/index.d.ts257 → 258, ADDED exactly[VALIDATION_FAILED_CODE], REMOVED empty, across all six declaration files underfiles[](⛔ not just the twoexportsentry points). - Limb 2 does not. Nothing narrows; the review
require()d both published CJS entry points on both legs and got byte-identical runtime behaviour. - ⇒
yes+minor+ no**BREAKING**banner — the banner is a limb-2 carrier. Identical in level and shape to all four landed siblings (feat(objectql): publish the registry's three conflict error codes as importable constants #16259e6279dc86, feat(objectql): publish ReadonlyFieldRejectedError's code as an importable constant #1628311f848e31, feat(objectql): publish DriverConnect, DatasourceUnavailable and SummaryRecompute error codes as constants #16308ddfbf04e9, feat(objectql): the two transaction-seam refusals publish their error code as a constant #16326eddd61201— allminor, zero banners).
⭐ The one item that could genuinely have bitten, checked with an independent probe rather than accepted: the emitted member goes readonly code = "VALIDATION_FAILED"; → readonly code: "VALIDATION_FAILED";. That is an emit form change with no type change — proven by a tsc --noEmit --strict probe with a firing negative control, plus a deliberately-widened code: string copy of the same probe that fails, so the probe can see the failure mode it guards. Had the member widened to string, a consumer's discriminated-union narrowing on err.code would have broken silently.
⚠️ Why it is still a draft
update_pull_request draft:false is refused with "API rate limit already exceeded for user ID 19182527". That budget is user-scoped and disjoint from the REST budget, which reads core 15000/15000 at this moment — so this is not a repository or permissions fact, and ⛔ REST PATCH {draft:false} is not a substitute: re-measured on this very PR just now, it returns 200 and changes nothing (draft=true before, draft=true after).
⇒ Nothing about this PR is holding it. The un-draft and auto-merge will be re-attempted on the next check-in.
Generated by Claude Code
Closes #16159
ValidationErrorpublishes its ADR-0112codeas an importable constant. This is the eleventh and last row of #16159's census, on the shape the four landed slices set (#16156, #16259, #16283, #16308, #16326): an exportedX_CODE = 'LITERAL' as const, the class field reading it as a barereadonly code = X_CODE;, a barrel re-export, and a pin test.All readings below were taken on this branch at
fc1e76583, in a dedicated worktree cut fromorigin/mainac76425f0.Why
Closesand notPart of— the census, re-run rather than inheritedThe card's table has 11 rows and this PR takes the last one. The count was re-derived with the card's own definition (a class extending
Errorthat carries a code), not by trusting the table:origin/mainac76425f0extends Errorinpackages/objectql/src, non-testfind-hook-result-shape.ts'sFindHookResultNotArrayErrorhas landed since, and it already readsFIND_HOOK_RESULT_NOT_ARRAY_CODE)readonly code =class fieldvalidation/record-validator.ts:136, this PRHookConditionErrorandRecordTitleFieldError, both deliberate and both saying so in their own docblocksnav-contribution-diagnostics.ts:93, a diagnostic interface, and itscodealready reads the exportedNAV_CONTRIBUTION_GROUP_MISSINGOther spellings were checked rather than assumed, because
readonly code =is only one of them:this.code =returns 0 in non-testpackages/objectql/src;readonly code:returns 1, the interface above; a barecode =class field returns 0; a class extending any error base other thanErrorreturns 0. So there is no twelfth row hiding under a different spelling, and this PR discharges the card.The wire string did not change
The quoted literal
'VALIDATION_FAILED'occurs exactly once in executable position on both sides of the diff — at base as the class field, at head as the constant's value. It moved; it did not multiply and it did not mutate. (The other occurrences in that file are prose inside docblocks, at base and at head alike.)It does not answer the question the card fenced off
secret-fields.tspublishesEMPTY_CREDENTIAL_REFUSAL_CODE = 'VALIDATION_ERROR'while this site spells'VALIDATION_FAILED', and the card says in its own words that "whether they should converge is a question this card does not answer". Publishing the current spelling leaves that decision exactly as open as it was — a convergence is a breaking rename of a registered wire code either way — and a pin test asserts the two are still two, so a future ruling has to argue for itself rather than arrive as a side effect. That open question is filed as #16440, because the card carrying the note is the one this PR discharges. #16440 stays open and is out of scope here.Clause-② —
yes, measuredAblation over every declaration file
files[]publishes (filesis['dist','README.md','CHANGELOG.md'], so all six.d.ts/.d.mtscount, not just the two entry points), comparing exported NAME SETS rather than bytes, because a byte diff here is dominated bytsup's internal alias renumbering and the content-hashed chunk filename, neither of which is a surface change:dist/index.d.tsVALIDATION_FAILED_CODEdist/index.d.mtsVALIDATION_FAILED_CODEdist/core.d.ts/.d.mtsdist/util-*.d.ts/.d.mtsbh, an internal aliasAdditive widening, limb 1, nothing removed ⇒
Clause-②: yesand aminorchangeset. No**BREAKING**banner is owed: that is the limb-2 narrowing shape, and this narrows nothing.The member-level reading, which the name set is blind to by construction, and it did move: the emitted class member goes from
readonly code = "VALIDATION_FAILED";toreadonly code: "VALIDATION_FAILED";. That is an emit-form change with no type change — proved with a standalonetsc --strictprobe that asserts bidirectional assignability between the two forms and carries a firing negative control (a different literal is refused, so the member is not silentlystring). It is also the emit form every landed sibling already ships:DriverConnectErrorreadsreadonly code: "ERR_DRIVER_CONNECT";in today'sdist.Byte-diff hunks, classified rather than counted:
index.d.tsis 2939 lines on both legs and differs on exactly two — line 1 (the content-hashed chunk filename) and line 2 (the export list, gainingb2 as VALIDATION_FAILED_CODEand renumbering the aliases after it).core.d.tsdiffers only by that filename, which is why its name set is unmoved.Ablation hygiene: the mutation was proven on disk (
git hash-objectequal to the base blob and different from the HEAD blob, both files, plus a zero-count anchor onVALIDATION_FAILED_CODE); the rebuild was proven rather than assumed (dist/index.d.tsmtime1788743496⇒1788743598, chunkutil-Dw5ZTIII⇒util-CTOUTmfv); the restore is byte-exact (git diff HEADempty, both blobs equal to their HEAD blobs) and the restore-leg rebuild reproduced the headdistsha-for-sha, so no mutated artifact survived into any later measurement.The docblock-ordering trap, proved in the emitted output
Two consecutive top-level JSDoc blocks both attach to the declaration that follows, which is what stranded three class docblocks in #16308. The constant and its docblock therefore sit above the class. Verified in the built
dist/util-Dw5ZTIII.d.tswith the package's owntsupand no turbo cache on the path: the docblock lands ondeclare const VALIDATION_FAILED_CODE, anddeclare class ValidationErrorfollows carrying exactly the documentation it had at base — which is none, since this class has never had a docblock of its own. Nothing was stranded and nothing was invented.The two gates are inverted, and this row lands on the provenance side
VALIDATION_FAILEDis registered inERROR_CODE_LEDGERunder@objectstack/objectql(packages/spec/src/api/error-code-ledger.zod.ts, the last entry of that owner's block), so:check:error-code-provenanceis non-neutral and moved by exactly one: 317 ⇒ 318 stamp sites, 301 ⇒ 302 listed, waivers unchanged at 16, exit 0 on both legs. It accepts the newconstdefsite because the code is listed under this package's own owner key.check:dispatcher-error-vocabularyis neutral by construction: that table records UNREGISTERED sites, so a registered code is invisible to it.VALIDATION_FAILEDreturns 0 inpackages/runtime/src/dispatcher-error-vocabulary.tsagainst a firing control ofDUPLICATE_ARTIFACT_OBJECT_NAME= 2 in the same file. The gate readsOK — 66 unregistered code-stamping site(s), all classified, unchanged. No cross-package edit is owed.Consumer-side census — measured, and nothing outside
packages/objectqlis rewired'VALIDATION_FAILED'is re-authored as an inline literal at 148 non-test sites across 33 files. The headline number is not the interesting part; the classification is, and it cuts the other way from what a raw count suggests:@objectstack/rest's own response bodies and itsVALIDATION_FAILED: …message-prefix convention (31 sites inrest-server.tsalone),plugin-approvals' identical message-prefix convention (26 sites, plus one site that consumes its own prefix),plugin-sharing'sSharingCriteriaValidationError— declared locally on purpose, its docblock saying "so a security guard in a plugin never depends on another package's build output at runtime" — and@objectstack/metadata-protocol's own class, whose docblock calls the code "this package's own house code". Folding any of these in would be a cross-package coupling this card never asked for.packages/types/src/validation-failure.tsandpackages/rest/src/error-response.tseach testcode === 'VALIDATION_FAILED' || name === 'ValidationError',error-response.tstests the wire body'scodea second time, andpackages/plugins/plugin-auth/src/objectql-adapter.tsdoes the same to map an engine refusal onto abetter-authAPIError. Each holds its own copy of the string today.packages/client/src/index.tsis prose only: all seven occurrences are JSDoc and comments, including the documented branchif (err.code === 'VALIDATION_FAILED'), and the client reads the HTTP envelope rather than this class.No consumer is rewired here. The card's scope is the producer-side importable constant.
./coreasymmetry, recorded not decidedValidationErroris published from the lean./coreentry as well as the batteries barrel; its constant, like every existing*_CODEin this package, goes on the barrel only (core.tscontains zero_CODEexports, anddist/core.d.ts's name set is 79 on both legs). That is batch-i's ruling A, applied unchanged, and it adds one more instance to the population #16260 owns — a question this PR deliberately leaves where it is. #16260 remains open and is not addressed here.Verification
Gate results are quoted from each gate's own verdict line; every exit code was captured by redirect-then-read, never through a pipe.
pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2over the 19 objectql test files namingValidationErrororVALIDATION_FAILED:Test Files 19 passed (19),Tests 503 passed (503), exit 0. Declared narrowing: the package has far more test files; CI runs the farm.pnpm --filter @objectstack/objectql typecheckexit 0, ratchet held at44 file(s) / 242 error(s) / 69 pinned signature(s). Not a bare exit 0: inclusion was proved withtsc --listFiles. The main program is 545 files and containsrecord-validator.tsandindex.tsbut not the new test file (so its green says nothing about the test); thetsconfig.test.jsonprogram is 1185 files and contains all three, including the new pin test. Absent-file control: 0 hits in both.pnpm lint(eslint . --no-inline-config) exit 0, run whole. No narrowing claimed.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(script blob verified identical toorigin/main's, so not a stale checker; it reports 58 runnable families). Run locally and green:check:nul-bytes(scanned 8080 text file(s) … no raw ASCII control bytes),check:error-code-casing,check:dispatcher-error-vocabulary,check:error-code-provenance,check:system-context-census(105 elevation read sites … all anchored),check:doc-authoring,check:published-files,check:engine-double-contract,check:where-matcher,check:objectql-double-limit,check:test-source-alias,check:type-source-resolution,check:cross-package-test-inputs,check:error-status-conformance,check:type-check-coverage,check:objectui-changeset,check:changeset-gate-self-tests,check:dts-closure(population checked, not quoted blind:15 built package(s) swept, objectql among them),check-changeset-no-major,check-adr-0087-registration,check-empty-changeset,check-changeset-fixed.path:NNNanchor even when it moves nothing else.check:system-context-censusis green; independently, 0path:NNNanchors reference either edited file from outsidepackages/objectql/src, against a firing control of 14 hits forregistry.ts/engine.tson the identical grep shape. Repo-wide there are 11 prose references of the formrecord-validator.ts:NNN, and every one of them was already stale onorigin/mainbefore this diff — checked line by line: the text cited at:471, verbatimfree-form (tags without options), lives at:790onorigin/main, andallowed.length > 0cited at:205/:212lives at:772. This diff shifts already-wrong anchors; it falsifies none that were true. Filed as [finding] a pinned NON-rule in packages/spec justifies itself with a record-validator.ts line number that is 319 lines off, and nothing checks prose line anchors #16441 rather than repaired here — that card is out of scope for this PR and remains open.check:dual-build-cjs-loadsexits 3 withRun pnpm build first. This is NOT a pass: nothing was measured, because 47 sibling packages have nodistin this worktree; it is CI's Build Core job that has the built closure.check:single-claim-pathsexits 2 withNOT WIRED — PR_NUMBER is not set … This is a wiring or usage failure, NOT a verdict; it is re-run against this PR number below.check-changeset-no-major's LEVEL AXIS reportsNOT MEASUREDlocally for the same reason — nopull_requestpayload — and itsno major bumplimb is green.Generated by Claude Code